poc: add schema command to fetch Clerk API specs#37
Conversation
Adds a new `clerk openapi` command that fetches OpenAPI specifications from the clerk/openapi-specs repository. Supports four public API names with options for version selection, format (YAML/JSON), and file output. Changes: - New command with public names: backend, frontend, platform, webhooks - Aliases for internal names: bapi→backend, fapi→frontend - 24-hour local caching to reduce network requests - Comprehensive test suite with 15 test cases covering aliases, versions, formats, error handling, and file output - Updated CLAUDE.md to require tests for new functionality Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Rename the CLI command from `clerk openapi` to `clerk schema`. Use public-facing names (backend, frontend, platform, webhooks) as primary identifiers while keeping internal aliases (bapi, fapi) working. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Support drilling into specific endpoints (e.g. `clerk schema backend /users`) or schema types (e.g. `clerk schema backend User`) instead of dumping the full spec. Add --resolve-refs flag to inline $ref references for self-contained output with circular reference detection. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
schema command to fetch Clerk API specs
| .argument("[api]", "API name: backend, frontend, platform, or webhooks") | ||
| .argument("[path]", "Endpoint path (e.g. /users) or schema type (e.g. User)") | ||
| .option("--spec-version <version>", "Spec version (default: latest)") | ||
| .option("--format <format>", "Output format: yaml (default) or json") |
There was a problem hiding this comment.
Nice to have: you could use .choices(["yaml", "json"]) here instead of the manual validation in schema(). Commander would reject invalid values automatically and display the allowed values in --help output. Same idea for the [api] argument -- .choices(["backend", "frontend", "platform", "webhooks", "bapi", "fapi"]) would give you free validation and help text.
.argument("[api]", "API name").choices(["backend", "frontend", "platform", "webhooks", "bapi", "fapi"])
// ...
.option("--format <format>", "Output format").choices(["yaml", "json"])| if (outputPath) { | ||
| await Bun.write(outputPath, content + "\n"); | ||
| console.error(`Spec written to ${outputPath}`); | ||
| } else { |
There was a problem hiding this comment.
Consider adding a --no-cache or --refresh flag. Right now there's no escape hatch if the cached file gets corrupted or the user needs a fresh copy within the 24h TTL window. Something like:
// in SchemaOptions
noCache?: boolean;
// in fetchSpec
if (!options.noCache) {
const cached = await readCache(api, version);
if (cached) return cached;
}| latest: string; | ||
| versions: string[]; | ||
| } | ||
|
|
There was a problem hiding this comment.
This version list will go stale whenever a new API version is released. You might want to add a comment noting that, or consider fetching the directory listing from the GitHub API at some point (e.g. GET repos/clerk/openapi-specs/contents/bapi) so the CLI can discover versions dynamically. For now, a TODO and maybe a simple test that fetches the repo to flag drift would be a low-cost safety net.
// TODO: consider discovering versions dynamically from the clerk/openapi-specs repo|
|
||
| // ── Ref resolution ─────────────────────────────────────────────────────────── | ||
|
|
||
| export function resolveAllRefs(node: unknown, root: unknown, seen?: Set<string>): unknown { |
There was a problem hiding this comment.
The visited.delete(refPath) pattern here is correct -- it tracks the current ancestor chain so sibling refs to the same schema both get resolved. Just wanted to confirm this is intentional since it's the kind of thing that looks like it might be a bug at first glance. Maybe a short comment would help future readers:
// Remove from visited so sibling references to the same schema
// are resolved (only circular *ancestor* chains are blocked).
visited.delete(refPath);
rafa-thayto
left a comment
There was a problem hiding this comment.
Hey! Nice feature, this would be really useful for agents and developers introspecting the API. A few things to address:
1. File paths are under src/ instead of packages/cli-core/src/
This branch looks like it predates the monorepo restructuring. Current main has all CLI source under packages/cli-core/src/. A rebase + move would be needed before this can merge.
2. Uses console.log/console.error directly
The project has a strict no-console oxlint rule and uses log.* methods everywhere (see .claude/rules/logging.md). This will fail the lint check.
import { log } from "../../lib/log.ts";
// For pipeable data output:
log.data(content);
// For status messages:
log.info(`Spec written to ${outputPath}`);
3. Tests delete the real CLERK_CACHE_DIR
beforeEach does await rm(CLERK_CACHE_DIR, { recursive: true, force: true }) which resolves to the developer's actual cache directory. Running tests locally would wipe your real CLI cache.
// Use a temp directory instead:
const testCacheDir = await mkdtemp(join(tmpdir(), "clerk-schema-test-"));
process.env.CLERK_CONFIG_DIR = testCacheDir;
4. Cache TTL mismatch
The README says "cached locally for 24 hours" but the code uses CACHE_TTL_MS which is 1 hour in constants.ts. Either update the docs or define a separate SCHEMA_CACHE_TTL_MS = 24 * 60 * 60 * 1000.
5. Tests should use captureLog() instead of spyOn(console)
The project has a captureLog() test utility that integrates with the log.* system. Once you switch from console.log to log.*, the tests should use captureLog() too.
6. Missing changeset
The Enforce Changeset workflow will block merge. This needs a minor changeset since it adds a new user-facing command.
7. resolveAllRefs cycle detection could be stricter
The visited set is shared across sibling properties with add/delete. For diamond-shaped ref patterns, cloning the set when entering a new ref would be safer:
visited.add(refPath);
const resolved = resolveAllRefs(target, root, new Set(visited));
visited.delete(refPath);
The core implementation is really well done though. The path introspection, type lookup, "did you mean?" suggestions, and circular ref handling are all solid. Just needs the structural updates to land on current main.
* fix(ci): split bun cache restore from save in setup-bun composite The composite previously used actions/cache@v5 (full) which auto-saves at job end, allowing PR-controlled code to write into the cache before save. Use actions/cache/restore@v5 for reads and gate actions/cache/save@v5 on github.event_name != 'pull_request' so only trusted runs (push to main, workflow_call from release.yml) populate the cache. * fix(ci): tighten setup-bun cache save gate - Gate the save step on the restore step's cache-hit output so we do not warn-spam when the key already exists. - Tighten the trust gate from github.event_name != 'pull_request' to github.event_name == 'push' so issue_comment (!snapshot) runs do not save the cache. Snapshot CI checks out PR-author-controlled code; only push-to-main is a structurally trusted save context. * fix(ci): use setup-bun composite in build/lint/test jobs Replaces inline oven-sh/setup-bun@v2 + actions/cache(/restore)@v5 + bun install steps with a single ./.github/actions/setup-bun reference. Combined with the composite's split restore/save and the github.event_name == 'push' trust gate, this resolves the 9 CodeQL alerts (actions/cache-poisoning/poisonable-step #29-#37) in ci.yml. The test-e2e job is unchanged. * refactor(ci): move bun cache save into push-only workflow The setup-bun composite previously contained a conditional cache save step. Even gated to push contexts, the save action's structural presence in a job reachable from a workflow_call that ultimately traces back to issue_comment (the !snapshot path) is enough for CodeQL's cache-poisoning rule to flag every step in the job. Move the cache save into a dedicated warm-bun-cache.yml workflow triggered only by push to main. The composite becomes restore-only and its read-write mode is removed (no callers used it after this refactor's build job switched to restore). PR runs read main's warmed cache via restore-keys fallback; the warmup workflow rebuilds the cache whenever bun.lock changes on main. * refactor(ci): inline setup-bun composite, gate cache restore on snapshot Removes the .github/actions/setup-bun composite action and inlines its operations (oven-sh/setup-bun@v2, optional actions/cache/restore@v5, bun install --frozen-lockfile) at all eight call sites. In ci.yml's build, lint, test, and test-e2e jobs, the cache restore is gated on github.event_name != 'issue_comment' so the snapshot path (which checks out PR-author-controlled code in the default branch's privileged context via release.yml's workflow_call) cannot read the cache. Other workflows (release.yml versioning/canary-version, notify-failure, notify-release) inline directly without a gate because they only run on trusted triggers. * refactor(ci): restore bun cache in notify workflows Both notify-failure and notify-release run on push-to-main triggered release workflows where the cache is already warm; adding the restore step avoids a cold install on every notification job.
Summary
clerk schemacommand that fetches OpenAPI specs from the clerk/openapi-specs repo--resolve-refsto inline all$refreferences for self-contained outputUsage
Changes
clerk schemacommand with public API names (backend, frontend, platform, webhooks) and internal aliases (bapi, fapi)/users) or types (User) with prefix-aware matching and "did you mean?" suggestions--resolve-refsflag: recursively inlines$refreferences with circular reference detectionTest plan
bun test— all 398 tests passbun run lint/bun run format— cleanclerk schema backend /users,clerk schema backend User, and--resolve-refsagainst live specs🤖 Generated with Claude Code